﻿using System;
using System.Configuration;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Web;
using System.Web.Hosting;
using EPiServer.Configuration;
using EPiServer.Web.Hosting;
using log4net;
using Encoder = System.Drawing.Imaging.Encoder;

namespace TemplateFoundation.Handlers.DynamicImageProcessor
{
    /// <summary>
    /// Dynamic image processor used to resize and/or crop images
    /// </summary>
    public sealed class Processor : IHttpHandler
    {
        private static readonly ILog _logger = LogManager.GetLogger(typeof (Processor));

        public Processor()
        {
            try
            {
                // Load configuration from configuration file, or fallback to default values
                Configuration = ConfigurationManager.GetSection("DynamicImageProcessor") as ProcessorConfig ??
                                new ProcessorConfig
                                {
                                    HighQuality = ProcessorDefaultConfig.HighQuality,
                                    Cache = { Enabled = ProcessorDefaultConfig.CacheEnabled, CacheDirectory = ProcessorDefaultConfig.CacheDirectory, AutomaticRefresh = ProcessorDefaultConfig.CacheAutomaticRefresh },
                                    DefaultMimeType = ProcessorDefaultConfig.DefaultMimeType,
                                    ImageNotFound = { FileName = ProcessorDefaultConfig.NotFoundImage, Enabled = ProcessorDefaultConfig.EnableImageNotFound }
                                };
            }
            catch (Exception ex)
            {
                _logger.ErrorFormat("[ETF] Unable to initialize ETF Dynamic Image Processor configuration: {0}", ex.Message);
                
                throw;
            }
        }

        #region Processor settings

        ProcessorConfig Configuration { get; set; }

        int ImageWidth { get; set; }
        int ImageHeight { get; set; }
        int CropWidth { get; set; }
        int CropHeight { get; set; }
        int OffsetX { get; set; }
        int OffsetY { get; set; }

        bool KeepAspectRatio { get; set; } // Whether resizing should be done while preserving original aspect ratio
        bool AllowScaleUp { get; set; } // Whether to allow images to be scaled up
        bool HighQualityRendering { get; set; } // Whether to use high quality rendering, defaults to configuration setting
        
        string RequestedImageUrl { get; set; }
        string RequestedImagePhysicalPath { get; set; }
        string MimeType { get; set; }
        string CacheFilePath { get; set; }

        Image OriginalImage { get; set; }
        VirtualFile OriginalImageFile { get; set; }

        private void ParseProcessorQueryStringParameters(HttpContext context)
        {          
            // Parse querystring parameters and default to 0 if the value hasn't been set (we ignore zero-values)
            ImageHeight = (context.Request.QueryString["h"] != null ? Convert.ToInt32(context.Request.QueryString["h"]) : 0);
            ImageWidth = (context.Request.QueryString["w"] != null ? Convert.ToInt32(context.Request.QueryString["w"]) : 0);
            KeepAspectRatio = !(context.Request.QueryString["r"] ?? string.Empty).Equals("false", StringComparison.OrdinalIgnoreCase); // Aspect ratio is kept by default
            CropHeight = (context.Request.QueryString["ch"] != null ? Convert.ToInt32(context.Request.QueryString["ch"]) : 0);
            CropWidth = (context.Request.QueryString["cw"] != null ? Convert.ToInt32(context.Request.QueryString["cw"]) : 0);
            OffsetX = (context.Request.QueryString["ox"] != null ? Convert.ToInt32(context.Request.QueryString["ox"]) : -1);
            OffsetY = (context.Request.QueryString["oy"] != null ? Convert.ToInt32(context.Request.QueryString["oy"]) : -1);
            AllowScaleUp = context.Request.QueryString["up"] != null;

            if (context.Request.QueryString["hq"] != null) // Use explicit rendering quality for this specific request
            {
                HighQualityRendering = context.Request.QueryString["hq"] == "true";
            }
            else // Use configuration setting
            {
                HighQualityRendering = Configuration.HighQuality;
            }
        }

        private VirtualFile GetOriginalImageFile()
        {
            return HostingEnvironment.VirtualPathProvider.GetFile(RequestedImageUrl);
        }

        private Image GetOriginalImage(HttpContext context, VirtualFile originalImageFile)
        {
            RequestedImagePhysicalPath = originalImageFile as UnifiedFile != null
                                      ? ((UnifiedFile)OriginalImageFile).LocalPath
                                      : context.Server.MapPath(RequestedImageUrl);

            Image image = null;

            // they are, lets get the original image
            if (string.IsNullOrEmpty(RequestedImagePhysicalPath))
            {
                using (var fs = OriginalImageFile.Open())
                {
                    image = Image.FromStream(fs);

                    fs.Close();
                }
            }
            else
            {
                if (File.Exists(RequestedImagePhysicalPath))
                {
                    image = Image.FromFile(RequestedImagePhysicalPath);
                }
                else
                {
                    _logger.WarnFormat("[ETF] Requested image does not exist: {0}", RequestedImagePhysicalPath);
                }
            }

            return image;
        }

        #endregion

        #region IHttpHandler members

        bool IHttpHandler.IsReusable
        {
            get { return false; }
        }
        
        void IHttpHandler.ProcessRequest(HttpContext context)
        {
            // Get the requested image URL
            RequestedImageUrl = context.Request.Path;

            // Process querystring parameters for processor options
            ParseProcessorQueryStringParameters(context);

            // Get the original image file
            OriginalImageFile = GetOriginalImageFile();

            // Get the original image
            OriginalImage = GetOriginalImage(context, OriginalImageFile);

            // Setup the Response, Clear out all the fun stuff
            context.Response.Clear();
            context.Response.BufferOutput = true;
            
            // Determine which options have been set
            var widthSpecified = ImageWidth > 0;
            var heightSpecified = ImageHeight > 0;
            var cropWidthSpecified = CropWidth > 0;
            var cropHeightSpecified = CropHeight > 0;

            // Determine suitable MIME type based on file extension
            DetermineMimeType();

            // Return cached copy if it exists and is up to date
            if (Configuration.Cache.Enabled)
            {
                CacheFilePath = GetCacheFileName(RequestedImageUrl, ImageWidth, ImageHeight, CropWidth, CropHeight, KeepAspectRatio, OffsetX, OffsetY, AllowScaleUp, HighQualityRendering);

                if (ImageHeight>0 || ImageWidth>0)
                {
                    if (File.Exists(CacheFilePath))
                    {
                        var cacheFileInfo = new FileInfo(CacheFilePath);

                        // Remove cached file if the original file has changed since the cached file was last saved
                        if (Configuration.Cache.AutomaticRefresh)
                        {
                            var originalImageFileInfo = new FileInfo(RequestedImagePhysicalPath);

                            if (originalImageFileInfo.LastWriteTimeUtc > cacheFileInfo.LastWriteTimeUtc)
                            {
                                File.Delete(CacheFilePath);
                            }
                        }
                        else
                        {
                            SetCachePolicy(context, cacheFileInfo.LastWriteTimeUtc);

                            if (!NotModifiedHandling(context, cacheFileInfo.LastWriteTime))
                            {
                                // sends the previously generated image
                                context.Response.ContentType = MimeType;
                                context.Response.WriteFile(CacheFilePath);
                                context.Response.Flush();
                            }

                            context.Response.End();
                            return;
                        }
                    }
                }
            }

            // Lets see if the requested file even exists (if the LocalPath property is null the VPP may serve content from an off-site location)
            if (!string.IsNullOrEmpty(RequestedImagePhysicalPath) && !File.Exists(RequestedImagePhysicalPath))
            {
                // it doesn't, so lets see if were using a not found image        
                if (Configuration.ImageNotFound.Enabled)
                {
                    // sends the not found image
                    context.Response.ContentType = MimeType;
                    context.Response.WriteFile(context.Server.MapPath(Configuration.ImageNotFound.FileName));
                    context.Response.Flush();
                    context.Response.End();
                    return;
                }

                // sends a 404
                context.Response.Clear();
                context.Response.StatusCode = 404;
                context.Response.End();
                return;
            }

            // lets check to see if requesting a custom resolution?
            if (ImageHeight != 0 || ImageWidth != 0 || CropHeight != 0 || CropWidth != 0)
            {
                // If cropping without resizing
                if ((CropHeight>0 || CropWidth>0) && (ImageWidth==0 && ImageHeight==0))
                {
                    // No custom size parameters, but crop options specified
                    ImageHeight = OriginalImage.Height;
                    ImageWidth = OriginalImage.Width;
                }

                // Now lets check if they are requesting an enlarged image (we only do that if explicitly requested with the "up" parameter)
                // Begin beautiful IF-craftmanship...
                var requestingEnlarged = 
                    ((ImageHeight > OriginalImage.Height && !widthSpecified)
                    || (ImageWidth > OriginalImage.Width && !heightSpecified)
                    || (ImageHeight > OriginalImage.Height && ImageWidth > OriginalImage.Width && widthSpecified && heightSpecified));

                var requestingSameSize = 
                    ((ImageHeight == OriginalImage.Height && !widthSpecified) // Only height specified
                    || (ImageWidth == OriginalImage.Width && !heightSpecified) // Only width specified
                    || (ImageHeight == OriginalImage.Height && ImageWidth == OriginalImage.Width && widthSpecified && heightSpecified)); // Height and width specified

                if (requestingSameSize)
                {
                    // Check if size-reducing cropping parameters have been specified

                    if ((cropHeightSpecified && CropHeight < OriginalImage.Height) || (cropWidthSpecified && CropWidth < OriginalImage.Width))
                    {
                        requestingSameSize = false; // At least one size-reducing crop setting was specified
                    }
                }

                if ((requestingEnlarged && !AllowScaleUp) || requestingSameSize) // If requesting enlarged without allowing up-scaling, or requesting original size, we return the original image
                {
                    context.Response.ContentType = MimeType;

                    if (string.IsNullOrEmpty(RequestedImagePhysicalPath))
                    {
                        // Read file stream and output it to the response stream
                        using (var fs = OriginalImageFile.Open())
                        {
                            using (var sr = new BinaryReader(fs))
                            {
                                var bytes = sr.ReadBytes((int)fs.Length);

                                context.Response.BinaryWrite(bytes);

                                sr.Close();
                            }

                            fs.Close();
                        }
                    }
                    else
                    {
                        var info = new FileInfo(RequestedImagePhysicalPath);
                        
                        SetCachePolicy(context, info.LastWriteTimeUtc);
                        
                        if (!NotModifiedHandling(context, info.LastWriteTime))
                        {
                            context.Response.WriteFile(RequestedImagePhysicalPath);
                        }
                    }

                    context.Response.Flush();
                    context.Response.End();

                    OriginalImage.Dispose();

                    return;
                }

                // now we do support aspect ration based request, so they can only change height or width, 
                // and we'll do the calculation so lets do the calculation. 

                double aspectratio = Convert.ToDouble(Convert.ToDouble(OriginalImage.Width) / Convert.ToDouble(OriginalImage.Height));

                if (ImageHeight>0 && ImageWidth>0 && KeepAspectRatio)
                {
                    // Both dimensions specified, keep aspect ratio and stay within boundaries
                    int wouldBeHeight = Convert.ToInt32(ImageWidth / aspectratio);
                    int wouldBeWidth = Convert.ToInt32(ImageHeight  * aspectratio);

                    if (wouldBeHeight>ImageHeight) // Going by width would result in an image being to high
                    {
                        ImageWidth = Convert.ToInt32(ImageHeight*aspectratio);
                    }
                    else if (wouldBeWidth>ImageWidth) // Going by height would result in an image being to wide
                    {
                        ImageHeight = Convert.ToInt32(ImageWidth / aspectratio);
                    }
                }
                else
                {
                    // If either height or width is left out the image will be scaled according to the dimension (height or width) specified
                    if (ImageHeight == 0)
                    {
                        ImageHeight = Convert.ToInt32(ImageWidth / aspectratio);
                    }
                    else if (ImageWidth == 0)
                    {
                        ImageWidth = Convert.ToInt32(ImageHeight  * aspectratio);
                    }
                }

                // ok now we have the requested dimensions. lets check to see if we've already generated their request. 
                if (Configuration.Cache.Enabled)
                {
                    CacheFilePath = GetCacheFileName(RequestedImageUrl, widthSpecified ? ImageWidth : 0, heightSpecified ? ImageHeight : 0, cropWidthSpecified ? CropWidth : 0, cropHeightSpecified ? CropHeight : 0, KeepAspectRatio, OffsetX, OffsetY, AllowScaleUp, HighQualityRendering);

                    if (File.Exists(CacheFilePath))
                    {
                        // we have, lets send that one, no need to regenerate a file we've already generated. 
                        context.Response.ContentType = MimeType;
                        var info = new FileInfo(CacheFilePath);
                        SetCachePolicy(context, info.LastWriteTimeUtc);
                        if (!NotModifiedHandling(context, info.LastWriteTime))
                        {

                            context.Response.WriteFile(CacheFilePath);
                            context.Response.Flush();
                        }
                        context.Response.End();

                        OriginalImage.Dispose();

                        return;

                    }
                }

                // ok now comes the dog and pony show, let's resize the image        
                var mybmp = new Bitmap(ImageWidth, ImageHeight);
                var objGraphics = Graphics.FromImage(mybmp);

                objGraphics.CompositingMode = CompositingMode.SourceOver;
                objGraphics.CompositingQuality = HighQualityRendering ? CompositingQuality.GammaCorrected : CompositingQuality.Default;

                objGraphics.PixelOffsetMode = HighQualityRendering? PixelOffsetMode.HighQuality : PixelOffsetMode.Default;

                objGraphics.InterpolationMode = HighQualityRendering ? InterpolationMode.HighQualityBicubic : InterpolationMode.Default;
                objGraphics.SmoothingMode = HighQualityRendering ? SmoothingMode.HighQuality : SmoothingMode.Default;

                objGraphics.DrawImage(OriginalImage, 0, 0, mybmp.Width, mybmp.Height);

                var qualityEncoder = Encoder.Quality;
                var colorEncoder = Encoder.ColorDepth;

                var ratio = new EncoderParameter(qualityEncoder, HighQualityRendering ? 100L : 80L);
                var light = new EncoderParameter(colorEncoder, 24L);
                var codecParameters = new EncoderParameters(2);
                codecParameters.Param[0] = ratio;
                codecParameters.Param[1] = light;

                const float brightness = 0;
                var bMatrix = new ColorMatrix(new[]
                                                  {
                                                      new Single[] {1, 0, 0, 0, 0},
                                                      new Single[] {0, 1, 0, 0, 0},
                                                      new Single[] {0, 0, 1, 0, 0},
                                                      new Single[] {0, 0, 0, 1, 0},
                                                      new[] {brightness, brightness, brightness, 0, 1}
                                                  });

                ApplyColorMatrix(ref mybmp, bMatrix);

                // Crop if crop height has been specified
                if (CropHeight > 0 && CropHeight < mybmp.Height)
                {
                    var heightDifference = mybmp.Height - CropHeight;
                    Rectangle rect;

                    if (OffsetY >= 0)
                    {
                        rect = new Rectangle(0, OffsetY, mybmp.Width, CropHeight);
                    }
                    else
                    {
                        rect = new Rectangle(0, heightDifference/2, mybmp.Width, CropHeight);
                    }

                    var cropped = mybmp.Clone(rect, mybmp.PixelFormat);
                    mybmp = cropped;
                }

                // Crop if crop width has been specified
                if (CropWidth > 0 && CropWidth < mybmp.Width)
                {
                    var widthDifference = mybmp.Width - CropWidth;
                    Rectangle rect;

                    if (OffsetX >= 0)
                    {
                        rect = new Rectangle(OffsetX, 0, CropWidth, mybmp.Height);
                    }
                    else
                    {
                        rect = new Rectangle(widthDifference/2, 0, CropWidth, mybmp.Height);
                    }

                    var cropped = mybmp.Clone(rect, mybmp.PixelFormat);
                    mybmp = cropped;
                }

                SetCachePolicy(context, DateTime.UtcNow);

                if (!NotModifiedHandling(context, DateTime.Now))
                {
                    if (MimeType == "image/png")
                    // Transparent PNG can only be written to seekable stream, using an intermediary memory stream
                    {
                        using (var ms = new MemoryStream())
                        {
                            mybmp.Save(ms, GetEncoderInfo(MimeType), codecParameters);

                            ms.WriteTo(context.Response.OutputStream);

                            ms.Close();
                        }
                    }
                    else
                    {
                        // Write image directly to output stream
                        mybmp.Save(context.Response.OutputStream, GetEncoderInfo(MimeType), codecParameters);
                    }

                    if (Configuration.Cache.Enabled)
                    {
                        // check to make sure the cache directory exists
                        if (!Directory.Exists(CacheFilePath.Substring(0, CacheFilePath.LastIndexOf("\\"))))
                            Directory.CreateDirectory(CacheFilePath.Substring(0, CacheFilePath.LastIndexOf("\\")));
                        // save the image to the cache directory
                        mybmp.Save(CacheFilePath, GetEncoderInfo(MimeType), codecParameters);
                    }
                    objGraphics.Dispose();
                    mybmp.Dispose();

                    // send it back to the client
                    context.Response.ContentType = MimeType;
                    context.Response.Flush();
                }

                // Dispose objects
                OriginalImage.Dispose();
                objGraphics.Dispose();
                mybmp.Dispose();

                context.Response.End();

                return;
            }

            // if the file is there and no height or width changes are required. 
            // send the original file

            context.Response.ContentType = MimeType;
            context.Response.ContentEncoding = Encoding.UTF8;

            if (string.IsNullOrEmpty(RequestedImagePhysicalPath))
            {
                // Read file stream and output it to the response stream
                using (var fs = OriginalImageFile.Open())
                {
                    using (var sr = new BinaryReader(fs))
                    {
                        var bytes = sr.ReadBytes((int)fs.Length);

                        context.Response.BinaryWrite(bytes);

                        sr.Close();
                    }

                    fs.Close();
                }
            }
            else
            {
                var info = new FileInfo(RequestedImagePhysicalPath);
                SetCachePolicy(context, info.LastWriteTimeUtc);
                if (!NotModifiedHandling(context, info.LastWriteTime))
                {
                    context.Response.WriteFile(RequestedImagePhysicalPath);
                }

            }

            context.Response.Flush();
            context.Response.End();
        }

        private void DetermineMimeType()
        {
            var fileExtension = Path.GetExtension(RequestedImageUrl);
            switch (fileExtension)
            {
                case ".jpg":
                case ".jpeg":
                    MimeType = "image/jpeg";
                    break;
                case ".png":
                    MimeType = "image/png";
                    break;
                case ".gif":
                    MimeType = "image/gif";
                    break;
                default: // Otherwise use default from config
                    MimeType = Configuration.DefaultMimeType;
                    break;
            }
        }

        #endregion

        /// <summary>
        /// Gets the filename to use for a cached copy of an image based on its modification settings
        /// </summary>
        /// <returns>Complete path and filename to use for the locally cached image copy</returns>
        private string GetCacheFileName(string imageUrl, int requestedWidth, int requestedHeight, int CropWidth, int CropHeight, bool KeepAspectRatio, int OffsetX, int OffsetY, bool scaleUp, bool highQuality)
        {
            var sb = new StringBuilder(); // Used to construct the file path for the cache file

            var fileName = Path.GetFileName(imageUrl);

            var fileExtension = Path.GetExtension(fileName);

            sb.Append(Configuration.Cache.CacheDirectory); // Add cache folder path

            sb.Append(imageUrl.Replace("/", "\\").Replace(fileName, string.Empty)); // Add image path, without the filename, to cache base path

            sb.AppendFormat("[{0}]", fileName); // We put the original filename inside brackets

            if (requestedHeight>0)
            {
                sb.AppendFormat(".h-{0}", requestedHeight);
            }

            if (requestedWidth>0)
            {
                sb.AppendFormat(".w-{0}", requestedWidth);
            }

            if (KeepAspectRatio && requestedHeight>0 && requestedWidth>0)
            {
                sb.Append(".skewed");
            }

            if (CropHeight>0)
            {
                sb.AppendFormat(".ch-{0}", CropHeight);
            }

            if (CropWidth>0)
            {
                sb.AppendFormat(".cw-{0}", CropWidth);
            }

            if(OffsetX >= 0)
            {
                sb.AppendFormat(".ox-{0}", OffsetX);
            }

            if(OffsetY >= 0)
            {
                sb.AppendFormat(".oy-{0}", OffsetY);
            }

            if (scaleUp)
            {
                sb.Append(".scaleUp");
            }

            if (highQuality)
            {
                sb.Append(".hq");
            }

            sb.Append(fileExtension);

            return sb.ToString();
        }

        /// <summary>
        /// Returns true if the file modification time is after the request's If-Modified-Since header
        /// </summary>
        /// <param name="context"></param>
        /// <param name="modifiedDate"></param>
        /// <returns></returns>
        private static bool CheckIsModifiedAndAddETag(HttpContext context, DateTime modifiedDate)
        {
            DateTime time;

            string s = context.Request.Headers["If-Modified-Since"];

            context.Response.AppendHeader("ETag", "\"" + modifiedDate.ToFileTime().ToString("X8", CultureInfo.InvariantCulture) + "\"");

            return (!DateTime.TryParse(s, out time) || (modifiedDate > time.AddSeconds(1.0)));
        }

        /// <summary>
        /// Sets a 'Not mofified' response code if the modified date isn't hit by the If-Modified-Since request header
        /// </summary>
        /// <param name="context"></param>
        /// <param name="modifiedDate"></param>
        /// <returns></returns>
        private static bool NotModifiedHandling(HttpContext context, DateTime modifiedDate)
        {
            if (!CheckIsModifiedAndAddETag(context, modifiedDate))
            {
                context.Response.AddHeader("Content-Length", "0");
                context.Response.StatusCode = 304;
                return true;
            }
            return false;
        }

        /// <summary>
        /// Adds set expiration headers 60 minutes ahead in time
        /// </summary>
        /// <param name="context"></param>
        /// <param name="fileChangedDate"></param>
        private static void SetCachePolicy(HttpContext context, DateTime fileChangedDate)
        {
            if(fileChangedDate > DateTime.UtcNow)
            {
                fileChangedDate = DateTime.UtcNow;
            }

            context.Response.Cache.SetLastModified(fileChangedDate);
            context.Response.Cache.SetExpires(DateTime.UtcNow.AddMinutes(60));
            context.Response.Cache.SetValidUntilExpires(true);
            context.Response.Cache.VaryByParams.IgnoreParams = true;
            context.Response.Cache.SetOmitVaryStar(true);
        }

        private static void ApplyColorMatrix(ref Bitmap myImage, ColorMatrix myMatrix)
        {
            var imgAtt = new ImageAttributes();
            var bmpMatrix = new Bitmap(myImage.Width, myImage.Height);
            var grMatrix = Graphics.FromImage(bmpMatrix);

            imgAtt.SetColorMatrix(myMatrix);

            grMatrix.DrawImage(myImage, new Rectangle(0, 0, myImage.Width, myImage.Height), 0, 0, myImage.Width, myImage.Height, GraphicsUnit.Pixel, imgAtt);

            myImage = bmpMatrix;

            grMatrix.Dispose();

            imgAtt.Dispose();
        }

        private static ImageCodecInfo GetEncoderInfo(string mime)
        {
            var encoders = ImageCodecInfo.GetImageEncoders();
            return encoders.FirstOrDefault(e => e.MimeType == mime);
        }
    }

}
